your personal website on atproto - mirror blento.app
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

at fix-edit-button 183 lines 6.1 kB view raw
1<script lang="ts"> 2 import type { EventData } from '$lib/cards/social/EventCard'; 3 import { getCDNImageBlobUrl } from '$lib/atproto'; 4 import { Avatar as FoxAvatar, Badge } from '@foxui/core'; 5 import Avatar from 'svelte-boring-avatars'; 6 7 let { data } = $props(); 8 9 let events: EventData[] = $derived(data.events); 10 let did: string = $derived(data.did); 11 let hostProfile = $derived(data.hostProfile); 12 13 let hostName = $derived(hostProfile?.displayName || hostProfile?.handle || did); 14 let hostUrl = $derived( 15 hostProfile?.url ?? `https://bsky.app/profile/${hostProfile?.handle || did}` 16 ); 17 18 function formatDate(dateStr: string): string { 19 const date = new Date(dateStr); 20 const options: Intl.DateTimeFormatOptions = { 21 weekday: 'short', 22 month: 'short', 23 day: 'numeric' 24 }; 25 if (date.getFullYear() !== new Date().getFullYear()) { 26 options.year = 'numeric'; 27 } 28 return date.toLocaleDateString('en-US', options); 29 } 30 31 function formatTime(dateStr: string): string { 32 return new Date(dateStr).toLocaleTimeString('en-US', { 33 hour: 'numeric', 34 minute: '2-digit' 35 }); 36 } 37 38 function getModeLabel(mode: string): string { 39 if (mode.includes('virtual')) return 'Virtual'; 40 if (mode.includes('hybrid')) return 'Hybrid'; 41 if (mode.includes('inperson')) return 'In-Person'; 42 return 'Event'; 43 } 44 45 function getModeColor(mode: string): 'cyan' | 'purple' | 'amber' | 'secondary' { 46 if (mode.includes('virtual')) return 'cyan'; 47 if (mode.includes('hybrid')) return 'purple'; 48 if (mode.includes('inperson')) return 'amber'; 49 return 'secondary'; 50 } 51 52 function getLocationString(locations: EventData['locations']): string | undefined { 53 if (!locations || locations.length === 0) return undefined; 54 55 const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 56 if (!loc) return undefined; 57 58 const flat = loc as Record<string, unknown>; 59 const nested = loc.address; 60 61 const locality = (flat.locality as string) || nested?.locality; 62 const region = (flat.region as string) || nested?.region; 63 64 const parts = [locality, region].filter(Boolean); 65 return parts.length > 0 ? parts.join(', ') : undefined; 66 } 67 68 function getThumbnail(event: EventData): { url: string; alt: string } | null { 69 if (!event.media || event.media.length === 0) return null; 70 const media = event.media.find((m) => m.role === 'thumbnail'); 71 if (!media?.content) return null; 72 const url = getCDNImageBlobUrl({ did, blob: media.content, type: 'jpeg' }); 73 if (!url) return null; 74 return { url, alt: media.alt || event.name }; 75 } 76 77 function getRkey(event: EventData): string { 78 return event.url.split('/').pop() || ''; 79 } 80 81 let actorPrefix = $derived(data.hostProfile?.handle ? `/${data.hostProfile.handle}` : `/${did}`); 82</script> 83 84<svelte:head> 85 <title>{hostName} - Events</title> 86 <meta name="description" content="Events hosted by {hostName}" /> 87 <meta property="og:title" content="{hostName} - Events" /> 88 <meta property="og:description" content="Events hosted by {hostName}" /> 89 <meta name="twitter:card" content="summary" /> 90 <meta name="twitter:title" content="{hostName} - Events" /> 91 <meta name="twitter:description" content="Events hosted by {hostName}" /> 92</svelte:head> 93 94<div class="bg-base-50 dark:bg-base-950 min-h-screen px-6 py-12 sm:py-12"> 95 <div class="mx-auto max-w-4xl"> 96 <!-- Header --> 97 <div class="mb-8"> 98 <h1 class="text-base-900 dark:text-base-50 mb-2 text-2xl font-bold sm:text-3xl"> 99 Upcoming events 100 </h1> 101 <div class="flex items-center gap-2 mt-4"> 102 <span class="text-base-500 dark:text-base-400 text-sm">Hosted by</span> 103 <a 104 href={hostUrl} 105 target={hostProfile?.hasBlento ? undefined : '_blank'} 106 rel={hostProfile?.hasBlento ? undefined : 'noopener noreferrer'} 107 class="flex items-center gap-1.5 hover:underline" 108 > 109 <FoxAvatar src={hostProfile?.avatar} alt={hostName} class="size-5 shrink-0" /> 110 <span class="text-base-900 dark:text-base-100 text-sm font-medium">{hostName}</span> 111 </a> 112 </div> 113 </div> 114 115 {#if events.length === 0} 116 <p class="text-base-500 dark:text-base-400 py-12 text-center">No events found.</p> 117 {:else} 118 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 119 {#each events as event (event.url)} 120 {@const thumbnail = getThumbnail(event)} 121 {@const location = getLocationString(event.locations)} 122 {@const rkey = getRkey(event)} 123 <a 124 href="{actorPrefix}/e/{rkey}" 125 class="border-base-200 dark:border-base-800 hover:border-base-300 dark:hover:border-base-700 group block overflow-hidden rounded-xl border transition-colors" 126 > 127 <!-- Thumbnail --> 128 {#if thumbnail} 129 <img 130 src={thumbnail.url} 131 alt={thumbnail.alt} 132 class="aspect-square w-full object-cover" 133 /> 134 {:else} 135 <div 136 class="bg-base-100 dark:bg-base-900 aspect-square w-full [&>svg]:h-full [&>svg]:w-full" 137 > 138 <Avatar 139 size={400} 140 name={rkey} 141 variant="marble" 142 colors={['#92A1C6', '#146A7C', '#F0AB3D', '#C271B4', '#C20D90']} 143 square 144 /> 145 </div> 146 {/if} 147 148 <!-- Content --> 149 <div class="p-4"> 150 <h2 151 class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mb-1 leading-snug font-semibold" 152 > 153 {event.name} 154 </h2> 155 156 <p class="text-base-500 dark:text-base-400 mb-2 text-sm"> 157 {formatDate(event.startsAt)} &middot; {formatTime(event.startsAt)} 158 </p> 159 160 <div class="flex flex-wrap items-center gap-2"> 161 {#if event.mode} 162 <Badge size="sm" variant={getModeColor(event.mode)} 163 >{getModeLabel(event.mode)}</Badge 164 > 165 {/if} 166 167 {#if location} 168 <span class="text-base-500 dark:text-base-400 truncate text-xs">{location}</span> 169 {/if} 170 </div> 171 172 {#if event.countGoing && event.countGoing > 0} 173 <p class="text-base-500 dark:text-base-400 mt-2 text-xs"> 174 {event.countGoing} going 175 </p> 176 {/if} 177 </div> 178 </a> 179 {/each} 180 </div> 181 {/if} 182 </div> 183</div>